Skip to content

fix(app-render): scope force-dynamic workStore mutations to their own segment#92064

Open
ossaidqadri wants to merge 1 commit intovercel:canaryfrom
ossaidqadri:fix/86424-force-dynamic-scope-propagation
Open

fix(app-render): scope force-dynamic workStore mutations to their own segment#92064
ossaidqadri wants to merge 1 commit intovercel:canaryfrom
ossaidqadri:fix/86424-force-dynamic-scope-propagation

Conversation

@ossaidqadri
Copy link
Copy Markdown

@ossaidqadri ossaidqadri commented Mar 29, 2026

Summary

  • export const dynamic = "force-dynamic" in a nested page was mutating the shared WorkStore and leaking state up to ancestor layouts, causing layouts explicitly marked force-static to incorrectly enter the PPR postpone path
  • Fix captures each segment's own dynamic config before children recurse, saves/restores the four dynamic fields (forceDynamic, forceStatic, dynamicShouldError, fetchCache) around the Promise.all traversal, and uses the captured local value in the PPR check instead of workStore.forceDynamic
  • Removes the long-standing TODO comment (lines 776-782) that acknowledged this exact broken behavior

Fixes #86424

Changes

  • packages/next/src/server/app-render/create-component-tree.tsx — 4 surgical changes to scope mutations per-segment
  • test/e2e/app-dir/force-dynamic-scoping/ — new e2e test fixture verifying a force-static layout prerenders at buildtime even when a nested page is force-dynamic

Test plan

  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/force-dynamic-scoping/
  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/app-static/
  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/dynamic-data/

… segment

Nested pages with `dynamic = "force-dynamic"` were mutating the shared
WorkStore and leaking that state up to ancestor layouts, causing layouts
explicitly marked `force-static` to incorrectly enter the PPR postpone
path. Capture each segment's own config before children recurse, save and
restore the four dynamic fields around the Promise.all traversal, and
use the captured local value in the PPR check instead of workStore.

Fixes vercel#86424
Copilot AI review requested due to automatic review settings March 29, 2026 01:07
@nextjs-bot
Copy link
Copy Markdown
Collaborator

Allow CI Workflow Run

  • approve CI run for commit: bde8284

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an App Router rendering bug where dynamic = "force-dynamic" in a nested page mutated shared WorkStore state and incorrectly forced ancestor layouts into the PPR postpone path, even when those layouts were configured force-static.

Changes:

  • Capture the current segment’s forceDynamic value before child traversal and use it for the PPR postpone decision.
  • Save/restore WorkStore dynamic-related fields around the parallel route (Promise.all) traversal to avoid child-to-parent state leakage.
  • Add a new e2e regression fixture + test to validate static parent layout + dynamic nested page behavior under PPR.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/next/src/server/app-render/create-component-tree.tsx Scopes WorkStore dynamic config mutations per segment and updates the PPR postpone check to avoid ancestor contamination.
test/e2e/app-dir/force-dynamic-scoping/force-dynamic-scoping.test.ts New regression test asserting a force-static layout prerenders at build time while a nested force-dynamic page renders at runtime (prod).
test/e2e/app-dir/force-dynamic-scoping/fixtures/next.config.js Enables experimental.ppr for the regression fixture.
test/e2e/app-dir/force-dynamic-scoping/fixtures/app/layout.js Root layout for the new fixture app.
test/e2e/app-dir/force-dynamic-scoping/fixtures/app/getSentinelValue.tsx Shared sentinel used to distinguish build-time vs runtime rendering.
test/e2e/app-dir/force-dynamic-scoping/fixtures/app/force-static-parent/layout.js Static parent layout segment under test.
test/e2e/app-dir/force-dynamic-scoping/fixtures/app/force-static-parent/force-dynamic-child/page.js Dynamic nested page segment under test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +725 to +731
// Restore workStore to this segment's values after children have finished.
// Children set their own dynamic config during their traversal; without this
// restore, the parent's PPR check (below) would see the last child's state.
workStore.forceDynamic = savedForceDynamic
workStore.forceStatic = savedForceStatic
workStore.dynamicShouldError = savedDynamicShouldError
workStore.fetchCache = savedFetchCache
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workStore dynamic fields are restored only after await Promise.all(...) completes successfully. If any child traversal throws/rejects, this function exits early and leaves workStore.forceDynamic/forceStatic/dynamicShouldError/fetchCache mutated for the remainder of the render, which can leak incorrect state into upstream error handling or subsequent logic. Wrap the Promise.all traversal in a try/finally (with the restore in finally) so restoration happens even on errors.

Copilot uses AI. Check for mistakes.
Comment on lines +796 to +799
// Only postpone if THIS segment explicitly set force-dynamic. We use
// segmentForceDynamic (captured before children ran) rather than
// workStore.forceDynamic so that a child's force-dynamic does not cause
// ancestor segments to postpone unnecessarily.
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says postponing happens only when this segment explicitly set force-dynamic, but segmentForceDynamic is captured from workStore.forceDynamic which can be true due to an ancestor segment (since forceDynamic is not cleared when a child is force-static/auto). Consider rewording to reflect that this is the force-dynamic state in effect for the current segment (captured before rendering children), not necessarily an explicit local config.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

force-dynamic incorrectly forces entire route tree to become dynamic (create-component-tree.tsx bug)

3 participants